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