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's postings to the running inventories (update
451 /// balances).
452 ///
453 /// # Precondition
454 ///
455 /// The transaction MUST already be booked — postings filled with complete
456 /// units and resolved costs, as produced by [`Self::book_and_interpolate`]
457 /// or the free [`book`](crate::book) function. Applying an *unbooked*
458 /// transaction can silently over-sell an inventory: a reduction with no
459 /// matching lot yet is dropped (its `reduce` error is otherwise ignored).
460 /// The loader pipeline guarantees this ordering; the in-loop `debug_assert`
461 /// below catches a violating caller in debug builds.
462 pub fn apply(&mut self, txn: &Transaction) {
463 for posting in &txn.postings {
464 if let Some(IncompleteAmount::Complete(units)) = &posting.units {
465 // Resolve the per-account booking method before mutably
466 // borrowing the inventories map.
467 let method = self.method_for(&posting.account);
468 let inv = self.inventories.entry(posting.account.clone()).or_default();
469
470 // Reduction vs augmentation — the single source for this decision
471 // (`Inventory::is_booking_reduction`), shared with the Late
472 // validator so the two can't drift (including the #1182 NONE gate
473 // that previously had to be maintained in both crates).
474 let is_reduction = inv.is_booking_reduction(units, posting.cost.as_ref(), method);
475
476 if is_reduction {
477 // Reduce from inventory. `reduce` only errors when the lot
478 // it would match is missing — a "must book first" precondition
479 // violation (see the fn-level doc). In release builds the
480 // historical behavior (ignore) is kept; in debug builds we
481 // surface the unbooked-apply bug instead of silently
482 // over-selling.
483 let reduced = inv.reduce(units, posting.cost.as_ref(), method);
484 debug_assert!(
485 reduced.is_ok(),
486 "apply() reduction failed — the transaction must be booked \
487 before apply() (postings filled, costs resolved); applying \
488 an unbooked reduction silently over-sells inventory"
489 );
490 // `reduced` is consumed only by the debug assertion above;
491 // release builds keep the historical ignore-the-Result behavior.
492 let _ = reduced;
493 } else {
494 // Add to inventory via the canonical cost-resolve shared with
495 // the Late validator, `build_balances`, and the query engine.
496 // Its per-unit / date / label handling matches the block this
497 // replaced (see `CostSpec::resolve`). The old inline price /
498 // cross-posting cost-currency inference is unnecessary here:
499 // `apply` is contracted to run on *booked* transactions (the
500 // `debug_assert` above; the production pipeline always
501 // `book_and_interpolate`s first), and booking fills the
502 // inferred currency into `cost_spec.currency`. Tests that call
503 // `apply` directly use explicit-currency fixtures, which need
504 // no inference.
505 inv.add(Position::from_posting(
506 units,
507 posting.cost.as_ref(),
508 txn.date,
509 ));
510 }
511 }
512 }
513 }
514
515 /// Book and interpolate a transaction.
516 ///
517 /// This fills in empty cost specs, then interpolates any missing amounts.
518 pub fn book_and_interpolate(
519 &self,
520 txn: &Transaction,
521 ) -> Result<InterpolationResult, BookingError> {
522 // Fast path: with no cost specs, `book` is an identity that only clones
523 // `txn` verbatim (profiling flagged that clone as ~6 MB / 10k txns — the
524 // common case). This method consumes only `booked.transaction` — the
525 // `gains` / `booked_indices` are unused here — and in the fast path that
526 // transaction *equals* `txn`, so `interpolate(&book(txn).transaction)`
527 // is provably identical to `interpolate(txn)`. Interpolate the original
528 // directly and skip the clone.
529 if !txn.postings.iter().any(|p| p.cost.is_some()) {
530 return Ok(interpolate(txn)?);
531 }
532
533 // First book (fill in costs)
534 let booked = self.book(txn)?;
535
536 // Then interpolate (fill in missing amounts)
537 let result = interpolate(&booked.transaction)?;
538
539 Ok(result)
540 }
541}
542
543/// Convert a core inventory `BookingError` into the booking-layer error,
544/// attaching the account context that the core layer doesn't carry.
545///
546/// All inventory-level failures funnel into a single
547/// [`BookingError::Inventory`] variant. The user-facing wording lives in the
548/// `Display` impl on [`AccountedBookingError`] so it cannot drift between
549/// the booking layer and the validator (#748 / #750).
550fn convert_core_booking_error(
551 err: rustledger_core::BookingError,
552 account: &rustledger_core::Account,
553) -> BookingError {
554 BookingError::Inventory(err.with_account(account.clone()))
555}
556
557/// Book and interpolate a list of transactions.
558///
559/// This processes transactions in order, tracking inventory to enable
560/// proper lot matching and capital gains calculation.
561pub fn book_transactions(
562 transactions: &[Transaction],
563 method: BookingMethod,
564) -> Vec<Result<InterpolationResult, BookingError>> {
565 let mut engine = BookingEngine::with_method(method);
566 let mut results = Vec::with_capacity(transactions.len());
567
568 for txn in transactions {
569 let result = engine.book_and_interpolate(txn);
570 if let Ok(ref interpolated) = result {
571 // Apply the booked transaction (with filled-in costs), not the original
572 engine.apply(&interpolated.transaction);
573 }
574 results.push(result);
575 }
576
577 results
578}
579
580/// Outcome of booking an entire ledger in one shot — see [`book`].
581#[derive(Debug, Clone)]
582pub struct LedgerBookResult {
583 /// Successfully booked directives, in the **original input order**.
584 /// Every `Transaction` has its cost specs filled and elided amounts
585 /// interpolated; all other directive kinds pass through unchanged.
586 pub booked: Vec<Directive>,
587 /// Directives whose `Transaction` failed to book, in original input
588 /// order, paired with the error. They are left in their pre-booking
589 /// shape so a caller can still surface the user's original input.
590 pub failed: Vec<(Directive, BookingError)>,
591}
592
593/// Book and interpolate every transaction in a ledger in one shot.
594///
595/// This is the standalone equivalent of the loader's internal booking
596/// pass. Transactions are processed in **booking order** — sorted by
597/// `(date, priority, has_cost_reduction)` — so lot matching and
598/// capital-gains tracking observe inventory in the correct sequence, while
599/// the returned [`LedgerBookResult::booked`] / [`LedgerBookResult::failed`]
600/// vectors preserve the caller's **original input order**. Non-transaction
601/// directives pass through untouched. Per-account booking methods declared
602/// via `Open ... "METHOD"` are honored; `method` is the fallback for
603/// accounts that declare none.
604///
605/// Booking is a pure function of its inputs, so calling it twice on the
606/// same `(directives, method)` yields equal results — this is the booking
607/// half of the #1235 pipeline-boundary invariants.
608#[must_use]
609pub fn book(directives: &[Directive], method: BookingMethod) -> LedgerBookResult {
610 let mut engine = BookingEngine::with_method(method);
611 engine.register_account_methods(directives.iter());
612
613 // Stable sort into booking order. Display order — `(date, priority,
614 // file position)` — is already encoded in the input's positional order,
615 // and a stable sort keeps that as the tiebreak.
616 let mut order: Vec<usize> = (0..directives.len()).collect();
617 order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i]));
618
619 // Book in booking order, recording each transaction's outcome against
620 // its original index so the result can be reassembled in input order.
621 let mut booked_txns: Vec<Option<Transaction>> = directives.iter().map(|_| None).collect();
622 let mut booking_errors: Vec<Option<BookingError>> = directives.iter().map(|_| None).collect();
623 for &i in &order {
624 if let Directive::Transaction(txn) = &directives[i] {
625 match engine.book_and_interpolate(txn) {
626 Ok(result) => {
627 // Apply the booked transaction (filled-in costs), not
628 // the original, so subsequent lot matching is correct.
629 engine.apply(&result.transaction);
630 booked_txns[i] = Some(result.transaction);
631 }
632 Err(e) => booking_errors[i] = Some(e),
633 }
634 }
635 }
636
637 // Reassemble in original input order, partitioning failures out.
638 let mut booked = Vec::with_capacity(directives.len());
639 let mut failed = Vec::new();
640 for (i, directive) in directives.iter().enumerate() {
641 if let Some(e) = booking_errors[i].take() {
642 failed.push((directive.clone(), e));
643 } else if let Some(txn) = booked_txns[i].take() {
644 booked.push(Directive::Transaction(txn));
645 } else {
646 booked.push(directive.clone());
647 }
648 }
649
650 LedgerBookResult { booked, failed }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use rust_decimal_macros::dec;
657 use rustledger_core::{NaiveDate, Posting, PriceAnnotation};
658
659 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
660 rustledger_core::naive_date(year, month, day).unwrap()
661 }
662
663 #[test]
664 fn test_book_simple_buy() {
665 let mut engine = BookingEngine::new();
666
667 // Buy 10 AAPL at $150
668 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
669 .with_synthesized_posting(
670 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
671 CostSpec::empty()
672 .with_number(rustledger_core::CostNumber::PerUnit {
673 value: dec!(150.00),
674 })
675 .with_currency("USD"),
676 ),
677 )
678 .with_synthesized_posting(Posting::new(
679 "Assets:Cash",
680 Amount::new(dec!(-1500.00), "USD"),
681 ));
682
683 engine.apply(&buy);
684
685 // Check inventory
686 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
687 assert_eq!(inv.units("AAPL"), dec!(10));
688 }
689
690 #[test]
691 fn test_book_sell_with_gain() {
692 let mut engine = BookingEngine::new();
693
694 // Buy 10 AAPL at $150
695 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
696 .with_synthesized_posting(
697 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
698 CostSpec::empty()
699 .with_number(rustledger_core::CostNumber::PerUnit {
700 value: dec!(150.00),
701 })
702 .with_currency("USD"),
703 ),
704 )
705 .with_synthesized_posting(Posting::new(
706 "Assets:Cash",
707 Amount::new(dec!(-1500.00), "USD"),
708 ));
709
710 engine.apply(&buy);
711
712 // Sell 5 AAPL at $175 with empty cost (needs lot matching)
713 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
714 .with_synthesized_posting(
715 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
716 .with_cost(CostSpec::empty()) // Empty - needs lot matching
717 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
718 )
719 .with_synthesized_posting(Posting::new(
720 "Assets:Cash",
721 Amount::new(dec!(875.00), "USD"),
722 ))
723 .with_synthesized_posting(Posting::auto("Income:CapitalGains")); // Elided
724
725 // Check inventory before sell
726 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
727 eprintln!("Inventory before sell: {inv:?}");
728
729 let booked = engine.book(&sell).unwrap();
730 eprintln!(
731 "Booked: gains={:?}, indices={:?}",
732 booked.gains, booked.booked_indices
733 );
734 eprintln!("Booked transaction: {:?}", booked.transaction);
735
736 // Check that gain was calculated
737 assert_eq!(
738 booked.gains.len(),
739 1,
740 "Expected 1 gain, got {:?}",
741 booked.gains
742 );
743 let gain = &booked.gains[0];
744 // Gain = 5 * (175 - 150) = 125
745 assert_eq!(gain.amount.number, dec!(125));
746 }
747
748 #[test]
749 fn test_book_with_total_cost() {
750 let mut engine = BookingEngine::new();
751
752 // Buy 1.763 VIIIX with total cost of 300 USD (like healthequity file)
753 let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
754 .with_synthesized_posting(
755 Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
756 CostSpec::empty()
757 .with_number(rustledger_core::CostNumber::Total {
758 value: dec!(300.00),
759 })
760 .with_currency("USD"),
761 ),
762 )
763 .with_synthesized_posting(Posting::new(
764 "Assets:Cash",
765 Amount::new(dec!(-300.00), "USD"),
766 ));
767
768 engine.apply(&buy);
769
770 // Check inventory
771 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
772 eprintln!("Inventory after total cost buy: {inv:?}");
773 assert_eq!(inv.units("VIIIX"), dec!(1.763));
774
775 // Check cost was calculated correctly (300/1.763 ≈ 170.16)
776 let pos = inv.positions().next().unwrap();
777 assert!(pos.cost.is_some(), "Expected cost on position");
778 eprintln!("Position cost: {:?}", pos.cost);
779 }
780
781 #[test]
782 fn test_book_total_cost_then_sell() {
783 // Test that book() correctly handles total cost syntax and preserves
784 // full precision for accurate capital gains calculation.
785 let mut engine = BookingEngine::new();
786
787 // Buy 1.763 VIIIX with total cost {{300.00 USD}}
788 let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
789 .with_synthesized_posting(
790 Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
791 CostSpec::empty()
792 .with_number(rustledger_core::CostNumber::Total {
793 value: dec!(300.00),
794 })
795 .with_currency("USD"),
796 ),
797 )
798 .with_synthesized_posting(Posting::new(
799 "Assets:Cash",
800 Amount::new(dec!(-300.00), "USD"),
801 ));
802
803 // Use book() to test the booking path with total cost
804 let booked_buy = engine.book(&buy).unwrap();
805 engine.apply(&booked_buy.transaction);
806
807 // Check that per-unit cost was calculated (300/1.763)
808 let buy_posting = &booked_buy.transaction.postings[0];
809 assert!(buy_posting.cost.is_some());
810 let cost_spec = buy_posting.cost.as_ref().unwrap();
811 // Booking should have converted the user-written Total into
812 // the post-booking PerUnitFromTotal shape — the per-unit value
813 // is computed for lot tracking and the total is preserved for
814 // exact residual math.
815 assert!(matches!(
816 cost_spec.number,
817 Some(rustledger_core::CostNumber::PerUnitFromTotal(_))
818 ));
819
820 // Sell all shares at $191 per unit
821 let sell = Transaction::new(date(2016, 6, 15), "Sell stock")
822 .with_synthesized_posting(
823 Posting::new("Assets:Stock", Amount::new(dec!(-1.763), "VIIIX"))
824 .with_cost(CostSpec::empty())
825 .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
826 )
827 .with_synthesized_posting(Posting::new(
828 "Assets:Cash",
829 Amount::new(dec!(336.73), "USD"), // 1.763 * 191 = 336.733
830 ))
831 .with_synthesized_posting(Posting::auto("Income:CapitalGains"));
832
833 let booked_sell = engine.book(&sell).unwrap();
834
835 // Capital gain should be: 336.73 - 300.00 = 36.73
836 // With full precision preserved, this should be accurate
837 assert_eq!(booked_sell.gains.len(), 1);
838 let gain = &booked_sell.gains[0];
839 // The gain should be close to 36.73 (sale proceeds - cost basis)
840 // Sale: 1.763 * 191 = 336.733, Cost: 300.00, Gain ≈ 36.73
841 eprintln!("Capital gain: {:?}", gain.amount);
842 }
843
844 #[test]
845 fn test_cost_spec_currency_inference() {
846 let mut engine = BookingEngine::new();
847
848 // SELLOPT: -1 AAPL {40.0} @ 0.4 USD — the cost has a number (40.0) but no
849 // cost currency. Booking infers it from the price annotation and fills it
850 // *into* the cost spec, so by the time `apply` runs the currency is already
851 // resolved. The production pipeline books before applying, so this drives
852 // that real `book_and_interpolate` → `apply` path rather than calling
853 // `apply` standalone.
854 let sell = Transaction::new(date(2022, 6, 17), "SELLOPT")
855 .with_synthesized_posting(
856 Posting::new("Assets:Stock", Amount::new(dec!(-1), "AAPL"))
857 .with_cost(
858 CostSpec::empty().with_number(rustledger_core::CostNumber::PerUnit {
859 value: dec!(40.0),
860 }),
861 )
862 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.4), "USD"))),
863 )
864 .with_synthesized_posting(Posting::new("Assets:Stock", Amount::new(dec!(40.0), "USD")));
865
866 let booked = engine
867 .book_and_interpolate(&sell)
868 .expect("booking should succeed");
869 engine.apply(&booked.transaction);
870
871 let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
872
873 // The AAPL position carries cost with the price-inferred USD currency.
874 let aapl_pos = inv
875 .positions()
876 .find(|p| p.units.currency.as_ref() == "AAPL")
877 .expect("Should have AAPL position");
878
879 assert!(aapl_pos.cost.is_some(), "AAPL position should have cost");
880 let cost = aapl_pos.cost.as_ref().unwrap();
881 assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
882 assert_eq!(cost.number, dec!(40.0), "Cost number should be 40.0");
883 }
884
885 #[test]
886 fn test_booking_engine_with_method() {
887 // Test that with_method creates engine with specified booking method
888 let engine = BookingEngine::with_method(BookingMethod::Lifo);
889 assert!(engine.inventories.is_empty());
890
891 // Also test default is FIFO
892 let default_engine = BookingEngine::new();
893 assert!(default_engine.inventories.is_empty());
894 }
895
896 #[test]
897 fn test_book_sell_with_total_price() {
898 let mut engine = BookingEngine::new();
899
900 // Buy 10 AAPL at $150
901 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
902 .with_synthesized_posting(
903 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
904 CostSpec::empty()
905 .with_number(rustledger_core::CostNumber::PerUnit {
906 value: dec!(150.00),
907 })
908 .with_currency("USD"),
909 ),
910 )
911 .with_synthesized_posting(Posting::new(
912 "Assets:Cash",
913 Amount::new(dec!(-1500.00), "USD"),
914 ));
915
916 engine.apply(&buy);
917
918 // Sell 5 AAPL with total price annotation (not per-unit)
919 // Total price = $875 for 5 shares = $175/share
920 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
921 .with_synthesized_posting(
922 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
923 .with_cost(CostSpec::empty())
924 .with_price(PriceAnnotation::total(Amount::new(dec!(875.00), "USD"))),
925 )
926 .with_synthesized_posting(Posting::new(
927 "Assets:Cash",
928 Amount::new(dec!(875.00), "USD"),
929 ))
930 .with_synthesized_posting(Posting::auto("Income:CapitalGains"));
931
932 let booked = engine.book(&sell).unwrap();
933
934 // Check that gain was calculated correctly
935 // Gain = 875 - (5 * 150) = 875 - 750 = 125
936 assert_eq!(booked.gains.len(), 1, "Expected 1 gain");
937 let gain = &booked.gains[0];
938 assert_eq!(gain.amount.number, dec!(125));
939 }
940
941 #[test]
942 fn test_book_transactions_multiple() {
943 // Buy 10 AAPL at $150
944 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
945 .with_synthesized_posting(
946 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
947 CostSpec::empty()
948 .with_number(rustledger_core::CostNumber::PerUnit {
949 value: dec!(150.00),
950 })
951 .with_currency("USD"),
952 ),
953 )
954 .with_synthesized_posting(Posting::new(
955 "Assets:Cash",
956 Amount::new(dec!(-1500.00), "USD"),
957 ));
958
959 // Sell 5 AAPL
960 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
961 .with_synthesized_posting(
962 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
963 .with_cost(CostSpec::empty())
964 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
965 )
966 .with_synthesized_posting(Posting::new(
967 "Assets:Cash",
968 Amount::new(dec!(875.00), "USD"),
969 ))
970 .with_synthesized_posting(Posting::auto("Income:CapitalGains"));
971
972 let transactions = vec![buy, sell];
973 let results = book_transactions(&transactions, BookingMethod::Fifo);
974
975 assert_eq!(results.len(), 2);
976 assert!(results[0].is_ok());
977 assert!(results[1].is_ok());
978 }
979
980 #[test]
981 fn test_book_augmentation_not_reduction() {
982 let mut engine = BookingEngine::new();
983
984 // First, add existing inventory with positive AAPL
985 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
986 .with_synthesized_posting(
987 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
988 CostSpec::empty()
989 .with_number(rustledger_core::CostNumber::PerUnit {
990 value: dec!(150.00),
991 })
992 .with_currency("USD"),
993 ),
994 )
995 .with_synthesized_posting(Posting::new(
996 "Assets:Cash",
997 Amount::new(dec!(-1500.00), "USD"),
998 ));
999
1000 engine.apply(&buy);
1001
1002 // Now try to book another buy (augmentation, not reduction)
1003 // This has empty cost but same sign as inventory, so it's not a reduction
1004 let another_buy = Transaction::new(date(2024, 2, 15), "Buy more")
1005 .with_synthesized_posting(
1006 Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
1007 .with_cost(CostSpec::empty()), // Empty cost but augmentation
1008 )
1009 .with_synthesized_posting(Posting::new(
1010 "Assets:Cash",
1011 Amount::new(dec!(-750.00), "USD"),
1012 ));
1013
1014 // Should not error - just skip lot matching for augmentation
1015 let booked = engine.book(&another_buy).unwrap();
1016 assert!(
1017 booked.booked_indices.is_empty(),
1018 "Augmentation should not have booked indices"
1019 );
1020 }
1021
1022 #[test]
1023 fn test_book_no_inventory_for_account() {
1024 let engine = BookingEngine::new();
1025
1026 // Try to book a sell without any prior inventory
1027 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1028 .with_synthesized_posting(
1029 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1030 .with_cost(CostSpec::empty()),
1031 )
1032 .with_synthesized_posting(Posting::new(
1033 "Assets:Cash",
1034 Amount::new(dec!(875.00), "USD"),
1035 ));
1036
1037 // Should succeed but with no booked indices (no inventory to match against)
1038 let booked = engine.book(&sell).unwrap();
1039 assert!(
1040 booked.booked_indices.is_empty(),
1041 "No inventory means no lot matching"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_book_zero_gain() {
1047 let mut engine = BookingEngine::new();
1048
1049 // Buy 10 AAPL at $150
1050 let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
1051 .with_synthesized_posting(
1052 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1053 CostSpec::empty()
1054 .with_number(rustledger_core::CostNumber::PerUnit {
1055 value: dec!(150.00),
1056 })
1057 .with_currency("USD"),
1058 ),
1059 )
1060 .with_synthesized_posting(Posting::new(
1061 "Assets:Cash",
1062 Amount::new(dec!(-1500.00), "USD"),
1063 ));
1064
1065 engine.apply(&buy);
1066
1067 // Sell at same price - zero gain
1068 let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
1069 .with_synthesized_posting(
1070 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1071 .with_cost(CostSpec::empty())
1072 .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
1073 )
1074 .with_synthesized_posting(Posting::new(
1075 "Assets:Cash",
1076 Amount::new(dec!(750.00), "USD"),
1077 ));
1078
1079 let booked = engine.book(&sell).unwrap();
1080
1081 // Zero gain should not be added to gains vector
1082 assert!(booked.gains.is_empty(), "Zero gain should not be recorded");
1083 }
1084
1085 /// Test cost currency inference from other postings (issue #230).
1086 ///
1087 /// When a cost is specified without a currency (e.g., `{1}`), the currency
1088 /// should be inferred from simple postings in the same transaction.
1089 #[test]
1090 fn test_cost_currency_inference_from_other_postings() {
1091 let mut engine = BookingEngine::new();
1092
1093 // Opening balance with cost without currency - should infer USD from other posting
1094 // 2026-01-01 * "Opening balance"
1095 // Assets:Abc 1 ABC {1} <- no currency, should infer USD
1096 // Equity:Opening-Balances -1 USD
1097 let open = Transaction::new(date(2026, 1, 1), "Opening balance")
1098 .with_synthesized_posting(
1099 Posting::new("Assets:Abc", Amount::new(dec!(1), "ABC")).with_cost(
1100 CostSpec::empty()
1101 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) }),
1102 ), // No currency!
1103 )
1104 .with_synthesized_posting(Posting::new(
1105 "Equity:Opening-Balances",
1106 Amount::new(dec!(-1), "USD"),
1107 ));
1108
1109 // Book and apply the opening
1110 let booked = engine.book(&open).unwrap();
1111 engine.apply(&booked.transaction);
1112
1113 // Check that the cost spec was filled in with USD
1114 let cost_spec = booked.transaction.postings[0].cost.as_ref().unwrap();
1115 assert_eq!(
1116 cost_spec.currency.as_deref(),
1117 Some("USD"),
1118 "Cost currency should be inferred as USD from other posting"
1119 );
1120
1121 // Check inventory has the position with correct cost
1122 let inv = engine.inventory(&"Assets:Abc".into()).unwrap();
1123 let pos = inv.positions().next().unwrap();
1124 assert!(pos.cost.is_some(), "Position should have cost");
1125 let cost = pos.cost.as_ref().unwrap();
1126 assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
1127 assert_eq!(cost.number, dec!(1), "Cost number should be 1");
1128
1129 // Now sell with explicit cost currency - should match the lot
1130 // 2026-01-02 * "Sale"
1131 // Assets:Abc -1 ABC {1 USD}
1132 // Expenses:Abc
1133 let sell = Transaction::new(date(2026, 1, 2), "Sale")
1134 .with_synthesized_posting(
1135 Posting::new("Assets:Abc", Amount::new(dec!(-1), "ABC")).with_cost(
1136 CostSpec::empty()
1137 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) })
1138 .with_currency("USD"),
1139 ),
1140 )
1141 .with_synthesized_posting(Posting::auto("Expenses:Abc"));
1142
1143 // This should succeed - the lot with {1 USD} should be found
1144 let booked_sell = engine.book(&sell).unwrap();
1145
1146 // Check that the lot was matched
1147 assert!(
1148 !booked_sell.booked_indices.is_empty(),
1149 "Sale should match the lot created in opening"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_multi_posting_crosses_lot_boundary() {
1155 // Regression test: Multiple postings in the same transaction reducing
1156 // the same commodity should correctly track inventory state across postings.
1157 // Previously, each posting would see the original inventory instead of
1158 // the updated state after processing previous postings.
1159
1160 let mut engine = BookingEngine::new();
1161
1162 // Create two lots of ADA with different costs
1163 // Lot 1: 100 ADA at $0.50 (2021-01-01)
1164 let buy1 = Transaction::new(date(2021, 1, 1), "Buy lot 1")
1165 .with_synthesized_posting(
1166 Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1167 CostSpec::empty()
1168 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.50) })
1169 .with_currency("USD")
1170 .with_date(date(2021, 1, 1)),
1171 ),
1172 )
1173 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1174 engine.apply(&buy1);
1175
1176 // Lot 2: 100 ADA at $0.52 (2022-05-19)
1177 let buy2 = Transaction::new(date(2022, 5, 19), "Buy lot 2")
1178 .with_synthesized_posting(
1179 Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
1180 CostSpec::empty()
1181 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.52) })
1182 .with_currency("USD")
1183 .with_date(date(2022, 5, 19)),
1184 ),
1185 )
1186 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-52), "USD")));
1187 engine.apply(&buy2);
1188
1189 // Verify initial inventory: 200 ADA total
1190 let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1191 assert_eq!(inv.units("ADA"), dec!(200));
1192
1193 // Consume half of lot 1 first
1194 let sell1 = Transaction::new(date(2022, 5, 20), "Sell 50 ADA")
1195 .with_synthesized_posting(
1196 Posting::new("Assets:Crypto", Amount::new(dec!(-50), "ADA"))
1197 .with_cost(CostSpec::empty()),
1198 )
1199 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(25), "USD")));
1200 let booked1 = engine.book(&sell1).unwrap();
1201 engine.apply(&booked1.transaction);
1202
1203 // Verify: 150 ADA remaining (50 in lot 1, 100 in lot 2)
1204 let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1205 assert_eq!(inv.units("ADA"), dec!(150));
1206
1207 // Now the critical test: TWO postings in the same transaction
1208 // that together cross the lot boundary.
1209 // - Posting 1: -75 ADA {} → takes 50 from lot 1 + 25 from lot 2
1210 // - Posting 2: -5 ADA {} → should take from lot 2 (continuing)
1211 let sell2 = Transaction::new(date(2022, 5, 22), "Sell 80 ADA (multi-posting)")
1212 .with_synthesized_posting(
1213 Posting::new("Assets:Crypto", Amount::new(dec!(-75), "ADA"))
1214 .with_cost(CostSpec::empty()),
1215 )
1216 .with_synthesized_posting(
1217 Posting::new("Assets:Crypto", Amount::new(dec!(-5), "ADA"))
1218 .with_cost(CostSpec::empty()),
1219 )
1220 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(42), "USD")));
1221
1222 // This should succeed - the bug was that the second posting would fail
1223 // with "No matching lot" because it was trying to match against lot 1
1224 // which was already exhausted by the first posting.
1225 let booked2 = engine.book(&sell2);
1226 assert!(
1227 booked2.is_ok(),
1228 "Multi-posting transaction should succeed: {:?}",
1229 booked2.err()
1230 );
1231
1232 // Apply and verify final inventory: 70 ADA remaining (all in lot 2)
1233 engine.apply(&booked2.unwrap().transaction);
1234 let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
1235 assert_eq!(
1236 inv.units("ADA"),
1237 dec!(70),
1238 "Should have 70 ADA remaining in lot 2"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_book_no_cost_specs_fast_path() {
1244 // Test that the fast path for transactions without cost specs
1245 // returns correct empty gains and booked_indices.
1246 let engine = BookingEngine::new();
1247
1248 // Simple expense transaction with no cost specs
1249 let txn = Transaction::new(date(2024, 1, 15), "Groceries")
1250 .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
1251 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
1252
1253 let result = engine.book(&txn).unwrap();
1254
1255 // Fast path should return empty gains and booked_indices
1256 assert!(result.gains.is_empty(), "Should have no capital gains");
1257 assert!(
1258 result.booked_indices.is_empty(),
1259 "Should have no booked indices"
1260 );
1261
1262 // Transaction should be unchanged
1263 assert_eq!(result.transaction.postings.len(), 2);
1264 assert_eq!(
1265 result.transaction.postings[0].units,
1266 Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD")))
1267 );
1268 }
1269
1270 /// Regression test for #748.
1271 ///
1272 /// The pta-standards `reduction-exceeds-inventory` conformance test
1273 /// asserts on `error_contains: ["not enough"]`. PR #745 made the booking
1274 /// layer propagate `InsufficientUnits` directly to the user instead of
1275 /// letting the validator's "Not enough units in ..." message win, which
1276 /// dropped the "not enough" phrasing. This test pins the user-facing
1277 /// Display string so the conformance assertion (and any downstream user
1278 /// tooling that greps the message) cannot regress silently again.
1279 ///
1280 /// After #750, the canonical Display lives on
1281 /// [`rustledger_core::AccountedBookingError`] and `BookingError::Inventory`
1282 /// delegates to it transparently — so this test exercises the same path
1283 /// the validator and `cmd/check.rs` use.
1284
1285 // =========================================================================
1286 // Regression test for issue #875 / beancount#889
1287 //
1288 // Scenario: buy stock with cost, sell without cost spec (leaves a simple
1289 // negative position), then buy more with cost spec. The third transaction
1290 // must succeed as an augmentation, not fail as a reduction.
1291 // =========================================================================
1292
1293 #[test]
1294 fn test_augmentation_after_sell_without_cost_spec() {
1295 // Regression test for issue #875 / beancount#889.
1296 //
1297 // Before the fix, the sell-without-cost-spec left a -25 HOOG simple
1298 // position, causing the subsequent buy-with-cost-spec to be
1299 // misclassified as a reduction (because is_reduced_by saw opposite
1300 // signs without distinguishing cost-bearing vs simple positions).
1301 let mut engine = BookingEngine::new();
1302
1303 // 2024-01-10: Buy 100 HOOG {1.50 EUR}
1304 let buy1 = Transaction::new(date(2024, 1, 10), "Buy 100 HOOG")
1305 .with_synthesized_posting(
1306 Posting::new("Assets:Stocks", Amount::new(dec!(100), "HOOG")).with_cost(
1307 CostSpec::empty()
1308 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.50) })
1309 .with_currency("EUR"),
1310 ),
1311 )
1312 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-150), "EUR")));
1313
1314 engine.apply(&buy1);
1315
1316 // 2024-01-15: Sell 25 HOOG without cost spec (price-only)
1317 let sell = Transaction::new(date(2024, 1, 15), "Sell 25 HOOG without cost spec")
1318 .with_synthesized_posting(
1319 Posting::new("Assets:Stocks", Amount::new(dec!(-25), "HOOG"))
1320 .with_price(PriceAnnotation::unit(Amount::new(dec!(1.60), "EUR"))),
1321 )
1322 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(40), "EUR")));
1323
1324 engine.apply(&sell);
1325
1326 // 2024-01-20: Buy 50 more HOOG {1.70 EUR} - this MUST succeed
1327 let buy2 = Transaction::new(date(2024, 1, 20), "Buy 50 more HOOG - should succeed")
1328 .with_synthesized_posting(
1329 Posting::new("Assets:Stocks", Amount::new(dec!(50), "HOOG")).with_cost(
1330 CostSpec::empty()
1331 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.70) })
1332 .with_currency("EUR"),
1333 ),
1334 )
1335 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-85), "EUR")));
1336
1337 // This should NOT fail. Before the fix, the engine would see the
1338 // -25 HOOG simple position and try to reduce, which would fail
1339 // because the cost spec wouldn't match any existing lot.
1340 let result = engine.book(&buy2);
1341 assert!(
1342 result.is_ok(),
1343 "Buy with cost spec after sell without cost spec should succeed as augmentation, \
1344 but got error: {:?}",
1345 result.err()
1346 );
1347
1348 let booked = result.unwrap();
1349 engine.apply(&booked.transaction);
1350
1351 // Verify final inventory state
1352 let inv = engine.inventory(&"Assets:Stocks".into()).unwrap();
1353 // 100 (original) - 25 (sold simple) + 50 (new lot) = 125 HOOG total
1354 assert_eq!(inv.units("HOOG"), dec!(125));
1355 }
1356
1357 #[test]
1358 fn test_insufficient_units_display_contains_not_enough() {
1359 let err = BookingError::Inventory(
1360 rustledger_core::BookingError::InsufficientUnits {
1361 currency: "AAPL".into(),
1362 requested: dec!(15),
1363 available: dec!(10),
1364 }
1365 .with_account("Assets:Stock".into()),
1366 );
1367 let rendered = format!("{err}");
1368 assert!(
1369 rendered.contains("not enough"),
1370 "InsufficientUnits Display must contain 'not enough' for beancount \
1371 compatibility (#748). Got: {rendered}"
1372 );
1373 assert!(
1374 rendered.contains("Assets:Stock"),
1375 "InsufficientUnits Display must include the account name. Got: {rendered}"
1376 );
1377 assert!(
1378 rendered.contains("15") && rendered.contains("10"),
1379 "InsufficientUnits Display must include requested and available amounts. Got: {rendered}"
1380 );
1381 }
1382
1383 /// Helper: does any posting still have an unfilled (elided) amount?
1384 fn has_elided_posting(txn: &Transaction) -> bool {
1385 txn.postings.iter().any(|p| p.units.is_none())
1386 }
1387
1388 #[test]
1389 fn book_interpolates_elided_posting_and_preserves_order() {
1390 use rustledger_core::Open;
1391
1392 let directives = vec![
1393 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1394 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1395 Directive::Transaction(
1396 Transaction::new(date(2024, 1, 15), "Lunch")
1397 .with_synthesized_posting(Posting::new(
1398 "Expenses:Food",
1399 Amount::new(dec!(50.00), "USD"),
1400 ))
1401 .with_synthesized_posting(Posting::auto("Assets:Cash")),
1402 ),
1403 ];
1404
1405 let result = book(&directives, BookingMethod::Strict);
1406 assert!(result.failed.is_empty(), "nothing should fail to book");
1407 assert_eq!(result.booked.len(), 3, "all directives preserved");
1408
1409 // Order preserved: the two Opens come first, unchanged.
1410 assert_eq!(result.booked[0], directives[0]);
1411 assert_eq!(result.booked[1], directives[1]);
1412
1413 // The transaction's elided posting got filled in.
1414 let Directive::Transaction(booked_txn) = &result.booked[2] else {
1415 panic!("third directive should still be a transaction");
1416 };
1417 assert!(
1418 !has_elided_posting(booked_txn),
1419 "the auto posting should have been interpolated"
1420 );
1421 }
1422
1423 #[test]
1424 fn book_is_deterministic() {
1425 use rustledger_core::Open;
1426
1427 let directives = vec![
1428 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1429 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1430 Directive::Transaction(
1431 Transaction::new(date(2024, 1, 15), "Buy")
1432 .with_synthesized_posting(
1433 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1434 .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
1435 )
1436 .with_synthesized_posting(Posting::auto("Assets:Cash")),
1437 ),
1438 ];
1439
1440 let first = book(&directives, BookingMethod::Strict);
1441 let second = book(&directives, BookingMethod::Strict);
1442 assert_eq!(
1443 first.booked, second.booked,
1444 "booking the same ledger twice must produce identical output"
1445 );
1446 }
1447
1448 #[test]
1449 fn book_partitions_failed_transaction() {
1450 use rustledger_core::Open;
1451
1452 // Buy a lot at $150, then sell against a cost basis ($200) that
1453 // matches no existing lot. Under Strict this is a no-matching-lot
1454 // error, so the sell is partitioned into `failed`.
1455 let buy_cost = CostSpec::empty()
1456 .with_number(rustledger_core::CostNumber::PerUnit {
1457 value: dec!(150.00),
1458 })
1459 .with_currency("USD");
1460 let sell_cost = CostSpec::empty()
1461 .with_number(rustledger_core::CostNumber::PerUnit {
1462 value: dec!(200.00),
1463 })
1464 .with_currency("USD");
1465
1466 let directives = vec![
1467 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1468 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1469 Directive::Transaction(
1470 Transaction::new(date(2024, 1, 10), "Buy")
1471 .with_synthesized_posting(
1472 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1473 .with_cost(buy_cost),
1474 )
1475 .with_synthesized_posting(Posting::new(
1476 "Assets:Cash",
1477 Amount::new(dec!(-1500.00), "USD"),
1478 )),
1479 ),
1480 Directive::Transaction(
1481 Transaction::new(date(2024, 1, 15), "Sell at phantom cost basis")
1482 .with_synthesized_posting(
1483 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1484 .with_cost(sell_cost),
1485 )
1486 .with_synthesized_posting(Posting::new(
1487 "Assets:Cash",
1488 Amount::new(dec!(1000.00), "USD"),
1489 )),
1490 ),
1491 ];
1492
1493 let result = book(&directives, BookingMethod::Strict);
1494 assert_eq!(result.failed.len(), 1, "the mismatched sell should fail");
1495 // The two Opens and the successful buy survive; the sell is dropped.
1496 assert_eq!(result.booked.len(), 3, "Opens + buy remain in booked");
1497 assert!(
1498 !result.booked.iter().any(|d| matches!(
1499 d,
1500 Directive::Transaction(t) if t.narration.as_ref() == "Sell at phantom cost basis"
1501 )),
1502 "failed sell must not appear in booked"
1503 );
1504 }
1505}