Skip to main content

rustledger_core/
cost.rs

1//! Cost and cost specification types.
2//!
3//! A [`Cost`] represents the acquisition cost of a position (lot). It includes
4//! the per-unit cost, currency, optional acquisition date, and optional label.
5//!
6//! A [`CostSpec`] is used for matching against existing costs or specifying
7//! new costs when all fields may not be known.
8
9// rkyv's enum derive (used on `CostNumber` below) synthesizes a
10// per-variant `Archived*` struct whose generated `pub value` field
11// doesn't inherit the source variant's field doc. Item-level
12// `#[allow(missing_docs)]` doesn't propagate into the macro-emitted
13// sibling items, so the suppression must live at module scope.
14// Limited to the `rkyv` feature so hand-written items still get the
15// lint under non-rkyv builds; reviewers should check that any new
16// rkyv-archived type added to this file has docs on its source fields
17// (review A-3.2).
18#![cfg_attr(feature = "rkyv", allow(missing_docs))]
19
20use crate::NaiveDate;
21use rust_decimal::Decimal;
22use serde::{Deserialize, Serialize};
23use std::fmt;
24
25use crate::Amount;
26
27// Note: We no longer auto-quantize calculated values during cost storage.
28// Python beancount preserves full precision during booking and only rounds
29// at display time. Premature rounding of per-unit costs (e.g., from
30// total cost / units) causes cost basis errors when selling.
31// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded to 170.17,
32// because 1.763 * 170.17 = 300.00971 ≠ 300.00.
33#[cfg(feature = "rkyv")]
34use crate::intern::{AsDecimal, AsNaiveDate};
35
36/// A cost represents the acquisition cost of a position (lot).
37///
38/// When you buy 10 shares of AAPL at $150 on 2024-01-15, the cost is:
39/// - number: 150
40/// - currency: "USD"
41/// - date: Some(2024-01-15)
42/// - label: None (or Some("lot1") if labeled)
43///
44/// # Examples
45///
46/// ```
47/// use rustledger_core::Cost;
48/// use rust_decimal_macros::dec;
49///
50/// let cost = Cost::new(dec!(150.00), "USD")
51///     .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
52///
53/// assert_eq!(cost.number, dec!(150.00));
54/// assert_eq!(cost.currency, "USD");
55/// assert!(cost.date.is_some());
56/// ```
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[cfg_attr(
59    feature = "rkyv",
60    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
61)]
62pub struct Cost {
63    /// Cost per unit
64    #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
65    pub number: Decimal,
66    /// Currency of the cost
67    pub currency: crate::Currency,
68    /// Acquisition date (optional, for lot identification)
69    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
70    pub date: Option<NaiveDate>,
71    /// Lot label (optional, for explicit lot identification)
72    pub label: Option<String>,
73}
74
75impl Cost {
76    /// Create a new cost with the given number and currency.
77    ///
78    /// Create a new cost with exact precision.
79    /// Use this for user-specified values that should preserve their precision.
80    #[must_use]
81    pub fn new(number: Decimal, currency: impl Into<crate::Currency>) -> Self {
82        Self {
83            number,
84            currency: currency.into(),
85            date: None,
86            label: None,
87        }
88    }
89
90    /// Create a new cost for calculated values.
91    ///
92    /// Previously this auto-quantized, but we now preserve full precision
93    /// to avoid cost basis errors. Rounding should only happen at display time.
94    #[must_use]
95    pub fn new_calculated(number: Decimal, currency: impl Into<crate::Currency>) -> Self {
96        Self::new(number, currency)
97    }
98
99    /// Add a date to this cost.
100    #[must_use]
101    pub const fn with_date(mut self, date: NaiveDate) -> Self {
102        self.date = Some(date);
103        self
104    }
105
106    /// Add an optional date to this cost.
107    #[must_use]
108    pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
109        self.date = date;
110        self
111    }
112
113    /// Add a label to this cost.
114    #[must_use]
115    pub fn with_label(mut self, label: impl Into<String>) -> Self {
116        self.label = Some(label.into());
117        self
118    }
119
120    /// Add an optional label to this cost.
121    #[must_use]
122    pub fn with_label_opt(mut self, label: Option<String>) -> Self {
123        self.label = label;
124        self
125    }
126
127    /// Get the cost as an amount.
128    #[must_use]
129    pub fn as_amount(&self) -> Amount {
130        Amount::new(self.number, self.currency.clone())
131    }
132
133    /// Calculate the total cost for a given number of units.
134    ///
135    /// `None` when `units × number` leaves `rust_decimal`'s ~±7.9e28 range —
136    /// which happens for inputs well below the ceiling, since a product needs
137    /// the sum of its operands' digits (#1863). There is no in-range value to
138    /// substitute: a clamped cost basis would be rendered as an exact total.
139    #[must_use]
140    pub fn total_cost(&self, units: Decimal) -> Option<Amount> {
141        Some(Amount::new(
142            units.checked_mul(self.number)?,
143            self.currency.clone(),
144        ))
145    }
146}
147
148impl fmt::Display for Cost {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        // Match Beancount's `Position.__str__` format: `{ 520 USD}` —
151        // single space after the opening brace, no space before the
152        // closing brace. The space matters for BQL-output compat: the
153        // compat harness diffs row-by-row against bean-query, and the
154        // pre-fix `{520 USD}` form accounted for ~137 of 510 file ×
155        // query mismatches. Verified against beanquery 0.2.0 + beancount
156        // 3.2.3 (matches what CI installs and what the dev shell now
157        // ships via the compat container — see PR #1047). Source-level
158        // `format_cost_spec` (used by `rledger format` to round-trip
159        // ledger files) keeps the no-space `{N CCY}` form because that
160        // matches Beancount's `print` command output, not its
161        // `Position.__str__`.
162        write!(f, "{{ {} {}", self.number, self.currency)?;
163        if let Some(date) = self.date {
164            write!(f, ", {date}")?;
165        }
166        if let Some(label) = &self.label {
167            // Escape via `format::escape_string` so labels containing
168            // `"`, `\`, or `\n` round-trip safely. Without this a label
169            // like `say "hi"` would render as `"say "hi""` — a parse
170            // error if anyone tried to feed it back to a Beancount-
171            // compatible reader.
172            write!(f, ", \"{}\"", crate::format::escape_string(label))?;
173        }
174        write!(f, "}}")
175    }
176}
177
178/// A cost specification for matching or creating costs.
179///
180/// Unlike [`Cost`], all fields are optional to allow partial matching.
181/// This is used in postings where the user may specify only some
182/// cost components (e.g., just the date to match a specific lot).
183///
184/// # Matching Rules
185///
186/// A `CostSpec` matches a `Cost` if all specified fields match:
187/// - If `number` is `Some`, it must equal the cost's number
188/// - If `currency` is `Some`, it must equal the cost's currency
189/// - If `date` is `Some`, it must equal the cost's date
190/// - If `label` is `Some`, it must equal the cost's label
191///
192/// # Examples
193///
194/// ```
195/// use rustledger_core::{Cost, CostSpec};
196/// use rust_decimal_macros::dec;
197///
198/// let cost = Cost::new(dec!(150.00), "USD")
199///     .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
200///
201/// // Match by date only
202/// let spec = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
203/// assert!(spec.matches(&cost));
204///
205/// // Match by wrong date
206/// let spec2 = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 16).unwrap());
207/// assert!(!spec2.matches(&cost));
208/// ```
209/// The numeric component of a [`CostSpec`].
210///
211/// Beancount cost specs name a number in one of two source-level
212/// shapes:
213///
214/// - `{150.00 USD}` — per-unit cost ([`Self::PerUnit`])
215/// - `{{ 1500.00 USD }}` — total cost for the posting's units
216///   ([`Self::Total`])
217///
218/// During booking the engine converts `Total(t)` into a third state,
219/// [`Self::PerUnitFromTotal`], carrying both the derived per-unit
220/// value (for display, lot tracking) and the original total (for
221/// precision-preserving residual math — division-then-multiplication
222/// loses precision at the `rust_decimal` 28-digit ceiling).
223///
224/// A cost spec without a number at all (e.g. `{}` for a booking-
225/// deferred lot match) is represented by `CostSpec.number: None`.
226///
227/// Pre-#1164 the per-unit and total numbers were two independent
228/// `Option<Decimal>` fields on `CostSpec`. The invalid both-set state
229/// was prevented only by parser discipline and downstream defensive
230/// branches; the "booked from total" state was modeled accidentally
231/// by setting both fields, with the meaning encoded only in code
232/// comments. Folding the axes into one enum makes both the
233/// pre-booking invalid state unrepresentable AND the post-booking
234/// derived state explicit.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
236#[cfg_attr(
237    feature = "rkyv",
238    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
239)]
240// Serde uses the `kind`-tagged internal representation so this enum
241// matches the wire shape used by FFI-WASI, WASM, Python compat, and
242// plugin-types. Pre-tag, serde defaulted to the external-tag form
243// (`{"PerUnit": "100"}`) which diverged from those boundaries —
244// downstream clients had to know which surface they were talking to.
245// (Module-level `allow(missing_docs)` at the top of this file
246// silences the rkyv-generated archived-struct field doc warnings —
247// see the file header comment.)
248#[serde(tag = "kind", rename_all = "snake_case")]
249pub enum CostNumber {
250    /// Per-unit cost as written: `{150.00 USD}`. Booking leaves this
251    /// shape unchanged.
252    PerUnit {
253        /// Per-unit value.
254        #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
255        value: Decimal,
256    },
257    /// Total cost as written: `{{ 1500.00 USD }}`. Booking rewrites
258    /// this to [`Self::PerUnitFromTotal`] once units are known.
259    Total {
260        /// Total value.
261        #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
262        value: Decimal,
263    },
264    /// Post-booking state: a per-unit value derived from a
265    /// `{{ total USD }}` spec at booking time, with the source total
266    /// preserved for exact residual math. Pre-#1164 this was modeled
267    /// implicitly by setting both `number_per` and `number_total` on
268    /// `CostSpec`. The payload is a separate [`BookedCost`] struct
269    /// so the booking-time invariant lives on a named type with
270    /// constructor methods that enforce it.
271    PerUnitFromTotal(BookedCost),
272    /// Compound cost as written: `{5.00 # 10.00 USD}` — per-unit AND a
273    /// lump total on top (beancount's `compound_amount`). On N units the
274    /// cost totals `N * per_unit + total`; booking rewrites this to
275    /// [`Self::PerUnitFromTotal`] with that combined total once units
276    /// are known. `{# 10.00 USD}` parses as `per_unit = 0` and `{5.00 #
277    /// USD}` as `total = 0` — arithmetically exact in both cases.
278    ///
279    /// Before #1700 the parser folded this form into [`Self::Total`]
280    /// with only the post-`#` value, silently misweighing every
281    /// compound spec (valid ledgers errored, invalid ones passed).
282    Compound {
283        /// Per-unit component (`a` in `{a # b}`); zero when omitted.
284        #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
285        per_unit: Decimal,
286        /// Lump total component (`b` in `{a # b}`); zero when omitted.
287        #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
288        total: Decimal,
289    },
290}
291
292/// Payload of [`CostNumber::PerUnitFromTotal`].
293///
294/// Carries both the per-unit value derived at booking time and the
295/// original `{{ total }}` cost so residual math can use the exact
296/// total (avoiding the division-then-multiplication precision loss
297/// that hits the `rust_decimal` 28-digit ceiling on long ledgers).
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
299#[cfg_attr(
300    feature = "rkyv",
301    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
302)]
303pub struct BookedCost {
304    /// Per-unit cost, derived as `total / |units|` during booking.
305    /// Used by lot tracking, display (Python-compat post-booking
306    /// per-unit form), and validation reads that want a per-unit
307    /// value.
308    #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
309    pub per_unit: Decimal,
310    /// Original total as written. Used by residual calculation to
311    /// avoid the division-then-multiplication precision loss that
312    /// would otherwise leak into balance checks.
313    #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
314    pub total: Decimal,
315}
316
317/// Diagnostic for a failed [`BookedCost`] consistency check.
318///
319/// Returned by [`BookedCost::try_new`] in three cases:
320/// - **Mismatch**: `per_unit * |units|` doesn't agree with `total` to
321///   within the `rust_decimal` rounding floor.
322/// - **Zero units**: every `per_unit` "works" by zero-multiplication
323///   so the invariant carries no information; the post-booking shape
324///   is structurally meaningless without units.
325/// - **Overflow**: `per_unit * |units|` would exceed `Decimal::MAX`
326///   (~7.92e28). Both operands fit in `Decimal` individually but their
327///   product doesn't. A wire client can reach this with extreme
328///   inputs; surfacing it as a typed error keeps the host from
329///   panicking on multiplication.
330///
331/// Carries the inputs and (for the mismatch case) the computed
332/// residual so trust-boundary callers can surface a meaningful error
333/// to the originating plugin or wire client ("you sent
334/// `per_unit=50, total=999` with `units=10`; derived total would be
335/// 500, off by 499 — far outside tolerance 1e-20"). Mapping this to a
336/// `ConversionError` variant gives plugin authors a typed category
337/// for the failure instead of conflating with `InvalidNumber` (parse
338/// failure).
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub struct BookedCostInvariantError {
341    /// The per-unit value the caller supplied.
342    pub per_unit: Decimal,
343    /// The total value the caller supplied.
344    pub total: Decimal,
345    /// The units value the caller supplied (caller-side sign retained
346    /// so error messages can show what came in).
347    pub units: Decimal,
348    /// `per_unit * |units|`, the value we'd expect `total` to equal.
349    /// `Decimal::ZERO` when the multiplication couldn't be performed
350    /// (zero units, or overflow — see [`Self::overflow`]).
351    pub derived_total: Decimal,
352    /// `|derived_total - total|`, the magnitude of the violation.
353    /// `Decimal::ZERO` for the zero-units and overflow cases.
354    pub abs_diff: Decimal,
355    /// The tolerance threshold we tested against. `None` when units
356    /// was zero or the multiplication overflowed — see
357    /// [`Self::overflow`] to distinguish the two.
358    pub tolerance: Option<Decimal>,
359    /// `true` when `per_unit * |units|` overflowed `Decimal::MAX`
360    /// (~7.92e28). Distinguishes the overflow case from the zero-units
361    /// case, since both leave `tolerance: None`.
362    pub overflow: bool,
363}
364
365impl fmt::Display for BookedCostInvariantError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        if self.overflow {
368            return write!(
369                f,
370                "BookedCost invariant check overflowed Decimal precision: per_unit ({}) * |units| ({}) exceeds Decimal::MAX (~7.92e28)",
371                self.per_unit,
372                self.units.abs(),
373            );
374        }
375        match self.tolerance {
376            Some(tol) => write!(
377                f,
378                "BookedCost invariant violated: per_unit ({}) * |units| ({}) = {} ≠ total ({}); abs_diff {} exceeds tolerance {}",
379                self.per_unit,
380                self.units.abs(),
381                self.derived_total,
382                self.total,
383                self.abs_diff,
384                tol,
385            ),
386            None => write!(
387                f,
388                "BookedCost requires non-zero units; got per_unit ({}), total ({}), units (0)",
389                self.per_unit, self.total,
390            ),
391        }
392    }
393}
394
395impl std::error::Error for BookedCostInvariantError {}
396
397impl BookedCost {
398    /// Check `per_unit * |units| ≈ total` to within `rust_decimal`
399    /// rounding tolerance, returning the diagnostic on failure.
400    ///
401    /// The booker derives `per_unit = total / |units|` at 28 significant
402    /// digits; back-multiplying truncates similarly. The residual can
403    /// reach a few ULP, which scales with `|total|`. Tolerance is
404    /// `max(1e-20, |total| * 1e-24)` — `1e-24` is ~10000x larger than
405    /// one ULP for typical magnitudes, while the absolute floor
406    /// guarantees a sane window for near-zero totals.
407    ///
408    /// **`units == 0` is rejected**: the post-booking shape implies the
409    /// booker derived `per_unit` from `total / |units|`, which is
410    /// undefined for zero units. A zero-units `PerUnitFromTotal` is
411    /// structurally meaningless and the caller should use the raw
412    /// `PerUnit` / `Total` variants. Pre-fix the zero-units case
413    /// short-circuited to `true`, which defeated the trust-boundary
414    /// guard at every input bridge (review B-3.1).
415    fn check_invariant(
416        per_unit: Decimal,
417        total: Decimal,
418        units: Decimal,
419    ) -> Result<(), BookedCostInvariantError> {
420        let units_abs = units.abs();
421        if units_abs.is_zero() {
422            return Err(BookedCostInvariantError {
423                per_unit,
424                total,
425                units,
426                derived_total: Decimal::ZERO,
427                abs_diff: Decimal::ZERO,
428                tolerance: None,
429                overflow: false,
430            });
431        }
432        // `per_unit` and `units_abs` each fit in `Decimal` individually
433        // (they came through `from_str_exact` or were constructed by
434        // the booker from values that did), but their product can
435        // exceed `Decimal::MAX` (~7.92e28). `Decimal::mul` panics on
436        // overflow — at a trust boundary that would crash the host
437        // from wire input, defeating `try_new`'s typed-error contract.
438        // Surface overflow as a typed error instead.
439        let Some(derived_total) = per_unit.checked_mul(units_abs) else {
440            return Err(BookedCostInvariantError {
441                per_unit,
442                total,
443                units,
444                derived_total: Decimal::ZERO,
445                abs_diff: Decimal::ZERO,
446                tolerance: None,
447                overflow: true,
448            });
449        };
450        let abs_diff = (derived_total - total).abs();
451        // `total.abs() * 1e-24` cannot overflow: `Decimal::MAX` is
452        // ~7.92e28, so the product is bounded by ~7.92e4. The relative
453        // tolerance scales with the magnitude of `total`; the absolute
454        // floor (`1e-20`) keeps the window sane for near-zero totals.
455        let relative = total.abs() * Decimal::new(1, 24);
456        let tolerance = if relative > Decimal::new(1, 20) {
457            relative
458        } else {
459            Decimal::new(1, 20)
460        };
461        if abs_diff <= tolerance {
462            Ok(())
463        } else {
464            Err(BookedCostInvariantError {
465                per_unit,
466                total,
467                units,
468                derived_total,
469                abs_diff,
470                tolerance: Some(tolerance),
471                overflow: false,
472            })
473        }
474    }
475
476    /// Construct from booking with a precision invariant check.
477    ///
478    /// In debug builds, asserts that `per_unit * |units| ≈ total` to
479    /// the limits of `rust_decimal` precision (tolerance:
480    /// `max(1e-20, |total| * 1e-24)`, derived from the booker's
481    /// `total / |units|` divisor truncating at 28 significant digits;
482    /// see the private `check_invariant` helper for the exact
483    /// computation). Callers are the booker (which derives
484    /// `per_unit = total / |units|`) and the plugin / FFI ingress
485    /// bridges (which must validate consistency before constructing).
486    ///
487    /// # Panics
488    ///
489    /// In debug builds: if the invariant fails. Release builds skip
490    /// the check (but trust-boundary callers should use
491    /// [`Self::try_new`] for runtime validation in release too).
492    #[must_use]
493    pub fn new(per_unit: Decimal, total: Decimal, units: Decimal) -> Self {
494        debug_assert!(
495            Self::check_invariant(per_unit, total, units).is_ok(),
496            "{}",
497            Self::check_invariant(per_unit, total, units).unwrap_err(),
498        );
499        Self { per_unit, total }
500    }
501
502    /// Try to construct, returning a typed error if the consistency
503    /// invariant fails. Use this at trust boundaries (FFI input,
504    /// plugin egress) where the caller may have supplied inconsistent
505    /// values and you want to reject rather than panic in debug or
506    /// accept silently in release.
507    ///
508    /// # Errors
509    ///
510    /// Returns [`BookedCostInvariantError`] when:
511    /// - `units == 0` (the post-booking shape is structurally
512    ///   undefined for zero units; callers should send `PerUnit` or
513    ///   `Total` instead).
514    /// - `per_unit * |units|` differs from `total` by more than
515    ///   `max(1e-20, |total| * 1e-24)`.
516    pub fn try_new(
517        per_unit: Decimal,
518        total: Decimal,
519        units: Decimal,
520    ) -> Result<Self, BookedCostInvariantError> {
521        Self::check_invariant(per_unit, total, units)?;
522        Ok(Self { per_unit, total })
523    }
524
525    /// Construct from rkyv archive bytes the host itself wrote.
526    ///
527    /// Bypasses the consistency invariant because rkyv archives carry
528    /// no units at the deserialization site, and the host invariant
529    /// was already enforced when the bytes were written. **Do not
530    /// call from boundary code** — every FFI / plugin / parser
531    /// ingress must go through [`Self::try_new`] (which surfaces a
532    /// typed error) so inconsistent pairs cannot enter the host.
533    ///
534    /// The name reflects the trust assumption: the caller has
535    /// verified (via cache-version checks, archive integrity, etc.)
536    /// that the bytes were produced by this host's own booker.
537    #[doc(hidden)]
538    #[must_use]
539    pub const fn from_archive_bytes_trusted(per_unit: Decimal, total: Decimal) -> Self {
540        Self { per_unit, total }
541    }
542
543    /// Construct an *intentionally inconsistent* `BookedCost` for
544    /// fuzzing trust-boundary code that must reject such inputs.
545    ///
546    /// Separate from [`Self::from_archive_bytes_trusted`] so the
547    /// "trusted" name doesn't lie at fuzz call sites — the fuzzer
548    /// explicitly generates pathological inputs. Gated behind the
549    /// `fuzz` Cargo feature so normal builds can't reach it (review
550    /// A-4.4); fuzz targets and integration tests that want to
551    /// stress trust-boundary code in convert bridges must opt in via
552    /// `features = ["fuzz"]` on their `rustledger-core` dep.
553    #[cfg(any(feature = "fuzz", test))]
554    #[doc(hidden)]
555    #[must_use]
556    pub const fn from_fuzz_unchecked(per_unit: Decimal, total: Decimal) -> Self {
557        Self { per_unit, total }
558    }
559}
560
561impl CostNumber {
562    /// Return the per-unit value if this number carries one.
563    ///
564    /// - [`Self::PerUnit`] → `Some(its Decimal)`
565    /// - [`Self::PerUnitFromTotal`] → `Some(per_unit)`
566    /// - [`Self::Total`] → `None` (booking hasn't computed per-unit yet)
567    #[must_use]
568    pub const fn per_unit(&self) -> Option<Decimal> {
569        match self {
570            Self::PerUnit { value } => Some(*value),
571            Self::PerUnitFromTotal(b) => Some(b.per_unit),
572            // The effective per-unit is (N*per_unit + total)/N — unknown
573            // until units are, same as Total.
574            Self::Total { .. } | Self::Compound { .. } => None,
575        }
576    }
577
578    /// Return the total value if this number carries one.
579    ///
580    /// - [`Self::Total`] → `Some(its Decimal)`
581    /// - [`Self::PerUnitFromTotal`] → `Some(total)`
582    /// - [`Self::PerUnit`] → `None`
583    #[must_use]
584    pub const fn total(&self) -> Option<Decimal> {
585        match self {
586            Self::Total { value } => Some(*value),
587            Self::PerUnitFromTotal(b) => Some(b.total),
588            // Compound's `total` field is only the lump component; the
589            // whole total (N*per_unit + total) needs units. Exposing the
590            // lump here would misweigh callers that treat this as the
591            // full total — the exact bug class this variant fixes.
592            Self::PerUnit { .. } | Self::Compound { .. } => None,
593        }
594    }
595
596    /// The total cost of `units` under this cost number, exhaustive over
597    /// every variant — stored totals are preferred for precision, exactly
598    /// like the `total()`-then-`per_unit()` chain, but `Compound` is
599    /// handled instead of silently dropped:
600    ///
601    /// - `Total` / `PerUnitFromTotal`: the stored total (precision-exact),
602    ///   with the sign following `units`.
603    /// - `PerUnit`: `value * units` (sign carries through `units`).
604    /// - `Compound { per_unit, total }`: `per_unit * units + total × sign(units)`
605    ///   (the `N·a + b` rule from #1704; `total` is only the lump).
606    ///
607    /// Stored totals and lumps are written as positive magnitudes in
608    /// source, so the sign is taken from `units` for EVERY variant — a
609    /// reduction (negative units) yields a negative total across the
610    /// board, the same sign rule the balance-weight ladder applies.
611    /// (Pre-fix, `Total`/`PerUnitFromTotal` returned the unsigned stored
612    /// total while `PerUnit` was signed through multiplication, so
613    /// reductions costed with opposite signs depending on the spec shape.)
614    ///
615    /// Consumers computing "what did N units cost" MUST use this rather
616    /// than chaining the `total()`/`per_unit()` accessors — both return
617    /// `None` for `Compound`, and accessor-chain fallbacks silently
618    /// miscost compound lots (the doctor/clamp bug class).
619    #[must_use]
620    pub fn total_for(&self, units: Decimal) -> Decimal {
621        use rust_decimal::prelude::Signed;
622        let signum = units.signum();
623        match self {
624            Self::Total { value } => *value * signum,
625            Self::PerUnitFromTotal(b) => b.total * signum,
626            Self::PerUnit { value } => *value * units,
627            Self::Compound { per_unit, total } => *per_unit * units + *total * signum,
628        }
629    }
630}
631
632/// A cost specification on a posting (`{...}` or `{{...}}`).
633///
634/// Carries the parsed cost-spec axes: the numeric component (per-unit
635/// vs total, modeled as the mutually-exclusive [`CostNumber`] enum),
636/// currency, lot date, label, and merge flag. Any subset may be
637/// missing — `{}` corresponds to all-fields-`None` plus `merge: false`,
638/// which lets the booker do lot matching deferred to inventory.
639///
640/// Pre-#1164 this struct had two independent `Option<Decimal>` fields
641/// (`number_per`, `number_total`). The mutual-exclusion invariant was
642/// enforced only by parser discipline; the post-booking "derived per-
643/// unit from total" state was modeled accidentally by setting both
644/// fields at once. The new shape (`number: Option<CostNumber>`) makes
645/// the invalid state unrepresentable and the derived state explicit.
646#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
647#[cfg_attr(
648    feature = "rkyv",
649    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
650)]
651pub struct CostSpec {
652    /// The numeric component: per-unit, total, or absent.
653    ///
654    /// Replaces the pre-#1164 `number_per` / `number_total` pair, which
655    /// allowed the invalid both-set state at the type level. See
656    /// [`CostNumber`] for the per-unit vs total distinction.
657    pub number: Option<CostNumber>,
658    /// Currency of the cost (if specified)
659    pub currency: Option<crate::Currency>,
660    /// Acquisition date (if specified)
661    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
662    pub date: Option<NaiveDate>,
663    /// Lot label (if specified)
664    pub label: Option<String>,
665    /// Whether to merge with existing lot (average cost)
666    pub merge: bool,
667}
668
669/// `a == b`, taking a cheap path when the two share a scale.
670///
671/// `Decimal`'s `PartialEq` is `self.cmp(other) == Equal`, and that comparison
672/// aligns scales before it can answer — it is not a representation check. Lot
673/// matching runs it once per lot per reduction, and it measured as 19.9% of a
674/// 20,000-transaction `investment` profile, growing 106x for 10x the input.
675///
676/// When the scales are equal the mantissa decides the value in BOTH
677/// directions: same scale and same mantissa is the same number, same scale
678/// and different mantissa cannot be. Only differing scales (`1.0` vs `1.00`)
679/// need the general comparison. `mantissa()` is signed, so a negative zero
680/// compares equal to a positive one, exactly as `cmp` has it.
681#[inline]
682fn same_number(a: Decimal, b: Decimal) -> bool {
683    if a.scale() == b.scale() {
684        a.mantissa() == b.mantissa()
685    } else {
686        a == b
687    }
688}
689
690impl CostSpec {
691    /// Create an empty cost spec.
692    #[must_use]
693    pub fn empty() -> Self {
694        Self::default()
695    }
696
697    /// Set the cost number directly.
698    ///
699    /// The mutual exclusion between per-unit and total is enforced by
700    /// the [`CostNumber`] enum — there is no way to set both. Callers
701    /// construct the variant explicitly:
702    ///
703    /// ```ignore
704    /// CostSpec::empty().with_number(CostNumber::PerUnit { value: dec!(150) });
705    /// CostSpec::empty().with_number(CostNumber::Total { value: dec!(1500) });
706    /// ```
707    ///
708    /// Pre-#1164 this slot was a pair of `Option<Decimal>` fields;
709    /// pre-this-PR there were `with_per_unit` / `with_total`
710    /// convenience shims that perpetuated the two-axis mental model
711    /// in caller code and silently overwrote each other if both were
712    /// called. Both are gone — there's exactly one way to set a cost
713    /// number now.
714    #[must_use]
715    pub const fn with_number(mut self, number: CostNumber) -> Self {
716        self.number = Some(number);
717        self
718    }
719
720    /// Set the currency.
721    #[must_use]
722    pub fn with_currency(mut self, currency: impl Into<crate::Currency>) -> Self {
723        self.currency = Some(currency.into());
724        self
725    }
726
727    /// Set the date.
728    #[must_use]
729    pub const fn with_date(mut self, date: NaiveDate) -> Self {
730        self.date = Some(date);
731        self
732    }
733
734    /// Set the label.
735    #[must_use]
736    pub fn with_label(mut self, label: impl Into<String>) -> Self {
737        self.label = Some(label.into());
738        self
739    }
740
741    /// Set the merge flag (for average cost booking).
742    #[must_use]
743    pub const fn with_merge(mut self) -> Self {
744        self.merge = true;
745        self
746    }
747
748    /// Check if this is an empty cost spec (all fields None).
749    #[must_use]
750    pub const fn is_empty(&self) -> bool {
751        self.number.is_none()
752            && self.currency.is_none()
753            && self.date.is_none()
754            && self.label.is_none()
755            && !self.merge
756    }
757
758    /// Check if this cost spec matches a cost.
759    ///
760    /// All specified fields must match the corresponding cost fields.
761    /// Per-unit matching uses `CostNumber::per_unit()` — a `Total`-only
762    /// spec doesn't constrain the per-unit lot value (booking hasn't
763    /// resolved it yet), but a `PerUnitFromTotal` post-booking spec
764    /// does.
765    #[must_use]
766    pub fn matches(&self, cost: &Cost) -> bool {
767        // Check per-unit cost — constrains the lot whenever the spec
768        // carries a per-unit value (PerUnit or PerUnitFromTotal).
769        if let Some(n) = self.number.and_then(|cn| cn.per_unit())
770            && !same_number(n, cost.number)
771        {
772            return false;
773        }
774        // Check currency
775        if let Some(c) = &self.currency
776            && c != &cost.currency
777        {
778            return false;
779        }
780        // Check date
781        if let Some(d) = &self.date
782            && cost.date.as_ref() != Some(d)
783        {
784            return false;
785        }
786        // Check label
787        if let Some(l) = &self.label
788            && cost.label.as_ref() != Some(l)
789        {
790            return false;
791        }
792        true
793    }
794
795    /// Resolve this cost spec to a concrete cost, given the number of units.
796    ///
797    /// If the number is `CostNumber::Total`, the per-unit cost is
798    /// calculated as `total / |units|`. Full precision is preserved to
799    /// avoid cost basis errors when the position is later sold.
800    /// `PerUnitFromTotal` already carries the derived per-unit value
801    /// from a prior booking pass — using `b.per_unit` directly is
802    /// equivalent to recomputing `b.total / |units|` because
803    /// [`BookedCost::new`] enforces that invariant at construction.
804    ///
805    /// Returns `None` if required fields (currency, number) are missing.
806    #[must_use]
807    pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
808        let currency = self.currency.clone()?;
809        let number = match self.number? {
810            // User-specified per-unit cost.
811            CostNumber::PerUnit { value: per } => per,
812            // Calculated from total — preserve full precision. Zero units make
813            // the per-unit cost undefined, and `total / 0` panics, so there is
814            // no cost to resolve: return `None` and let the caller book an
815            // uncosted position. Matches the zero-units guard in
816            // `BookingEngine::apply`; before this, `validate`/`pad` (which call
817            // `resolve`) panicked on `0 X {{n CUR}}`.
818            CostNumber::Total { value: total } => {
819                if units.is_zero() {
820                    return None;
821                }
822                total / units.abs()
823            }
824            // Compound `{a # b}`: effective per-unit is (N*a + b)/N —
825            // beancount's compound_amount. Same zero-units guard as Total.
826            CostNumber::Compound { per_unit, total } => {
827                if units.is_zero() {
828                    return None;
829                }
830                per_unit + total / units.abs()
831            }
832            // Already booked: `b.per_unit == b.total / |units|` by
833            // `BookedCost::new`'s invariant, so this is identical to
834            // the `Total` arm above but without the redivision.
835            CostNumber::PerUnitFromTotal(b) => b.per_unit,
836        };
837
838        Some(Cost {
839            number,
840            currency,
841            date: self.date.or(Some(date)),
842            label: self.label.clone(),
843        })
844    }
845}
846
847impl fmt::Display for CostSpec {
848    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
849        write!(f, "{{")?;
850        // Max 5 elements: number, currency, date, label, merge
851        let mut parts = Vec::with_capacity(5);
852
853        match self.number {
854            Some(CostNumber::PerUnit { value: n }) => parts.push(format!("{n}")),
855            Some(CostNumber::PerUnitFromTotal(b)) => parts.push(format!("{}", b.per_unit)),
856            Some(CostNumber::Total { value: n }) => parts.push(format!("# {n}")),
857            Some(CostNumber::Compound { per_unit, total }) => {
858                parts.push(format!("{per_unit} # {total}"));
859            }
860            None => {}
861        }
862        if let Some(c) = &self.currency {
863            parts.push(c.to_string());
864        }
865        if let Some(d) = self.date {
866            parts.push(d.to_string());
867        }
868        if let Some(l) = &self.label {
869            parts.push(format!("\"{l}\""));
870        }
871        if self.merge {
872            parts.push("*".to_string());
873        }
874
875        write!(f, "{}", parts.join(", "))?;
876        write!(f, "}}")
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use rust_decimal_macros::dec;
884
885    /// `total_for` must be exhaustive: every variant yields the correct
886    /// total for N units, including `Compound` (which the `total()` /
887    /// `per_unit()` accessors both refuse — the doctor/clamp bug class).
888    #[test]
889    fn total_for_covers_all_variants() {
890        let n = Decimal::from(10);
891        assert_eq!(
892            CostNumber::PerUnit {
893                value: Decimal::new(500, 2)
894            }
895            .total_for(n),
896            Decimal::new(5000, 2), // 10 * 5.00
897        );
898        assert_eq!(
899            CostNumber::Total {
900                value: Decimal::new(6000, 2)
901            }
902            .total_for(n),
903            Decimal::new(6000, 2), // stored total, precision-exact
904        );
905        let booked = BookedCost::new(Decimal::new(600, 2), Decimal::new(6000, 2), n);
906        assert_eq!(
907            CostNumber::PerUnitFromTotal(booked).total_for(n),
908            Decimal::new(6000, 2), // stored total preferred
909        );
910        assert_eq!(
911            CostNumber::Compound {
912                per_unit: Decimal::new(500, 2),
913                total: Decimal::new(1000, 2),
914            }
915            .total_for(n),
916            Decimal::new(6000, 2), // 10*5.00 + 10.00 = N*a + b
917        );
918    }
919
920    #[test]
921    fn total_for_signs_reductions_consistently() {
922        // Stored totals/lumps are positive magnitudes in source; the sign
923        // must come from `units` for EVERY variant, or reductions cost
924        // with opposite signs depending on spec shape (review catch on
925        // the original fix: Total/PerUnitFromTotal were unsigned while
926        // PerUnit signed through multiplication).
927        let n = Decimal::from(-10);
928        assert_eq!(
929            CostNumber::PerUnit {
930                value: Decimal::new(500, 2)
931            }
932            .total_for(n),
933            Decimal::new(-5000, 2),
934        );
935        assert_eq!(
936            CostNumber::Total {
937                value: Decimal::new(6000, 2)
938            }
939            .total_for(n),
940            Decimal::new(-6000, 2),
941        );
942        let booked = BookedCost::new(Decimal::new(600, 2), Decimal::new(6000, 2), n.abs());
943        assert_eq!(
944            CostNumber::PerUnitFromTotal(booked).total_for(n),
945            Decimal::new(-6000, 2),
946        );
947        assert_eq!(
948            CostNumber::Compound {
949                per_unit: Decimal::new(500, 2),
950                total: Decimal::new(1000, 2),
951            }
952            .total_for(n),
953            Decimal::new(-6000, 2), // -10*5.00 + 10.00*sign(-10)
954        );
955        // Zero units: everything costs zero.
956        assert_eq!(
957            CostNumber::Total {
958                value: Decimal::new(6000, 2)
959            }
960            .total_for(Decimal::ZERO),
961            Decimal::ZERO,
962        );
963    }
964
965    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
966        crate::naive_date(year, month, day).unwrap()
967    }
968
969    #[test]
970    fn test_cost_new() {
971        let cost = Cost::new(dec!(150.00), "USD");
972        assert_eq!(cost.number, dec!(150.00));
973        assert_eq!(cost.currency, "USD");
974        assert!(cost.date.is_none());
975        assert!(cost.label.is_none());
976    }
977
978    #[test]
979    fn test_cost_builder() {
980        let cost = Cost::new(dec!(150.00), "USD")
981            .with_date(date(2024, 1, 15))
982            .with_label("lot1");
983
984        assert_eq!(cost.date, Some(date(2024, 1, 15)));
985        assert_eq!(cost.label, Some("lot1".to_string()));
986    }
987
988    #[test]
989    fn test_cost_total() {
990        let cost = Cost::new(dec!(150.00), "USD");
991        let total = cost.total_cost(dec!(10)).expect("fixture fits in Decimal");
992        assert_eq!(total.number, dec!(1500.00));
993        assert_eq!(total.currency, "USD");
994    }
995
996    #[test]
997    fn test_resolve_total_cost_zero_units_returns_none() {
998        // `0 X {{100 USD}}`: a Total cost over zero units. Before the guard,
999        // `total / units.abs()` was `100 / 0`, which PANICKED in `validate`/`pad`
1000        // (which call `resolve`). It must now resolve to `None` (uncosted).
1001        let spec = CostSpec::empty()
1002            .with_number(CostNumber::Total {
1003                value: dec!(100.00),
1004            })
1005            .with_currency("USD");
1006        assert_eq!(spec.resolve(Decimal::ZERO, date(2024, 1, 15)), None);
1007        // Non-zero units still resolve to the per-unit cost.
1008        let cost = spec.resolve(dec!(4), date(2024, 1, 15)).unwrap();
1009        assert_eq!(cost.number, dec!(25)); // 100 / 4
1010    }
1011
1012    #[test]
1013    fn test_cost_display() {
1014        let cost = Cost::new(dec!(150.00), "USD")
1015            .with_date(date(2024, 1, 15))
1016            .with_label("lot1");
1017        let s = format!("{cost}");
1018        assert!(s.contains("150.00"));
1019        assert!(s.contains("USD"));
1020        assert!(s.contains("2024-01-15"));
1021        assert!(s.contains("lot1"));
1022    }
1023
1024    /// Exact-format regression covering both fixes in this PR:
1025    /// - leading space inside `{` (matches Beancount Position.__str__)
1026    /// - special-character escaping in labels via `format::escape_string`
1027    #[test]
1028    fn test_cost_display_escapes_special_characters_in_label() {
1029        // Bare per-unit cost — pin the leading-space form.
1030        let bare = Cost::new(dec!(520), "USD");
1031        assert_eq!(format!("{bare}"), "{ 520 USD}");
1032
1033        // With date.
1034        let dated = Cost::new(dec!(520.00), "USD").with_date(date(2024, 1, 15));
1035        assert_eq!(format!("{dated}"), "{ 520.00 USD, 2024-01-15}");
1036
1037        // Embedded double-quote.
1038        let quoted = Cost::new(dec!(100.00), "USD")
1039            .with_date(date(2024, 1, 15))
1040            .with_label("say \"hi\"");
1041        assert_eq!(
1042            format!("{quoted}"),
1043            "{ 100.00 USD, 2024-01-15, \"say \\\"hi\\\"\"}"
1044        );
1045
1046        // Embedded backslash.
1047        let backslash = Cost::new(dec!(50.00), "USD").with_label("path\\to\\lot");
1048        assert_eq!(
1049            format!("{backslash}"),
1050            "{ 50.00 USD, \"path\\\\to\\\\lot\"}"
1051        );
1052
1053        // Embedded newline.
1054        let newline = Cost::new(dec!(75.00), "USD").with_label("line1\nline2");
1055        assert_eq!(format!("{newline}"), "{ 75.00 USD, \"line1\\nline2\"}");
1056
1057        // Plain label still works (no escaping changes for safe chars).
1058        let plain = Cost::new(dec!(540.00), "USD")
1059            .with_date(date(2024, 2, 15))
1060            .with_label("lot-A");
1061        assert_eq!(format!("{plain}"), "{ 540.00 USD, 2024-02-15, \"lot-A\"}");
1062    }
1063
1064    #[test]
1065    fn test_cost_spec_empty() {
1066        let spec = CostSpec::empty();
1067        assert!(spec.is_empty());
1068    }
1069
1070    #[test]
1071    fn test_cost_spec_matches() {
1072        let cost = Cost::new(dec!(150.00), "USD")
1073            .with_date(date(2024, 1, 15))
1074            .with_label("lot1");
1075
1076        // Empty spec matches everything
1077        assert!(CostSpec::empty().matches(&cost));
1078
1079        // Match by number
1080        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1081            value: dec!(150.00),
1082        });
1083        assert!(spec.matches(&cost));
1084
1085        // Wrong number
1086        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1087            value: dec!(160.00),
1088        });
1089        assert!(!spec.matches(&cost));
1090
1091        // Match by currency
1092        let spec = CostSpec::empty().with_currency("USD");
1093        assert!(spec.matches(&cost));
1094
1095        // Match by date
1096        let spec = CostSpec::empty().with_date(date(2024, 1, 15));
1097        assert!(spec.matches(&cost));
1098
1099        // Match by label
1100        let spec = CostSpec::empty().with_label("lot1");
1101        assert!(spec.matches(&cost));
1102
1103        // Match by all
1104        let spec = CostSpec::empty()
1105            .with_number(crate::CostNumber::PerUnit {
1106                value: dec!(150.00),
1107            })
1108            .with_currency("USD")
1109            .with_date(date(2024, 1, 15))
1110            .with_label("lot1");
1111        assert!(spec.matches(&cost));
1112    }
1113
1114    #[test]
1115    fn test_cost_spec_resolve() {
1116        let spec = CostSpec::empty()
1117            .with_number(crate::CostNumber::PerUnit {
1118                value: dec!(150.00),
1119            })
1120            .with_currency("USD");
1121
1122        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
1123        assert_eq!(cost.number, dec!(150.00));
1124        assert_eq!(cost.currency, "USD");
1125        assert_eq!(cost.date, Some(date(2024, 1, 15)));
1126    }
1127
1128    #[test]
1129    fn test_cost_spec_resolve_total() {
1130        let spec = CostSpec::empty()
1131            .with_number(crate::CostNumber::Total {
1132                value: dec!(1500.00),
1133            })
1134            .with_currency("USD");
1135
1136        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
1137        assert_eq!(cost.number, dec!(150.00)); // 1500 / 10
1138        assert_eq!(cost.currency, "USD");
1139    }
1140
1141    // ===== BookedCost / PerUnitFromTotal tests (#1164) =====
1142
1143    #[test]
1144    fn booked_cost_new_accepts_consistent_pair() {
1145        // 10 units of "300 total" → 30 per-unit. Constructor must
1146        // accept; debug_assert sees per_unit * |units| == total.
1147        let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1148        assert_eq!(b.per_unit, dec!(30));
1149        assert_eq!(b.total, dec!(300));
1150    }
1151
1152    #[test]
1153    fn booked_cost_new_accepts_negative_units() {
1154        // Sales (negative units) still produce consistent
1155        // PerUnitFromTotal: per_unit * |units| == total uses .abs().
1156        let b = BookedCost::new(dec!(30), dec!(300), dec!(-10));
1157        assert_eq!(b.per_unit, dec!(30));
1158    }
1159
1160    #[test]
1161    #[should_panic(expected = "BookedCost invariant violated")]
1162    fn booked_cost_new_rejects_inconsistent_pair_in_debug() {
1163        // per_unit (50) * |units| (10) = 500, NOT 300. Invariant must
1164        // fire. Release builds would skip the check by design — this
1165        // test verifies the debug-build safety net.
1166        let _ = BookedCost::new(dec!(50), dec!(300), dec!(10));
1167    }
1168
1169    #[test]
1170    #[should_panic(expected = "requires non-zero units")]
1171    fn booked_cost_new_rejects_zero_units_in_debug() {
1172        // Post-A-3.5/B-3.1: zero units is structurally meaningless
1173        // for the post-booking shape (every per_unit "works" by
1174        // zero-multiplication). `new` debug-asserts and panics;
1175        // `try_new` returns a typed error. The booker never
1176        // constructs PerUnitFromTotal with zero units (see book.rs),
1177        // so this only fires when boundary code forgets to validate.
1178        let _ = BookedCost::new(dec!(7), dec!(99), dec!(0));
1179    }
1180
1181    #[test]
1182    fn booked_cost_from_archive_bytes_trusted_skips_invariant() {
1183        // rkyv deserialization uses this when units aren't at hand.
1184        // Constructs the inconsistent pair without panicking —
1185        // verifying it's truly unchecked. Plugin / FFI ingress code
1186        // must NOT use this path; they get `try_new`.
1187        let b = BookedCost::from_archive_bytes_trusted(dec!(50), dec!(300));
1188        assert_eq!(b.per_unit, dec!(50));
1189        assert_eq!(b.total, dec!(300));
1190    }
1191
1192    #[test]
1193    fn booked_cost_from_fuzz_unchecked_skips_invariant() {
1194        // Fuzz harness uses this to generate pathological inputs that
1195        // stress trust-boundary code in convert bridges. Distinct
1196        // from the archive constructor at the source level so grep
1197        // can identify each kind of bypass.
1198        let b = BookedCost::from_fuzz_unchecked(dec!(999999), dec!(0.01));
1199        assert_eq!(b.per_unit, dec!(999999));
1200        assert_eq!(b.total, dec!(0.01));
1201    }
1202
1203    #[test]
1204    fn booked_cost_try_new_rejects_inconsistent_pair_with_diagnostic() {
1205        // Trust-boundary constructor must return a typed error for
1206        // inconsistent pairs. 10 units × 50/u = 500, not 999.
1207        let err = BookedCost::try_new(dec!(50), dec!(999), dec!(10))
1208            .expect_err("expected invariant error for inconsistent input");
1209        assert_eq!(err.per_unit, dec!(50));
1210        assert_eq!(err.total, dec!(999));
1211        assert_eq!(err.units, dec!(10));
1212        assert_eq!(err.derived_total, dec!(500));
1213        assert_eq!(err.abs_diff, dec!(499));
1214        assert!(err.tolerance.is_some(), "tolerance must be reported");
1215        assert!(!err.overflow, "this case is mismatch, not overflow");
1216
1217        // Display includes both supplied and derived values for
1218        // plugin-author diagnostics.
1219        let msg = format!("{err}");
1220        assert!(msg.contains("50") && msg.contains("999") && msg.contains("500"));
1221    }
1222
1223    #[test]
1224    fn booked_cost_try_new_rejects_zero_units() {
1225        // Pre-fix the zero-units case short-circuited to "valid",
1226        // defeating the trust-boundary guard at every input bridge
1227        // (review B-3.1). PerUnitFromTotal is structurally
1228        // meaningless for zero units — every per_unit "works" by
1229        // zero-multiplication. Reject explicitly with `tolerance:
1230        // None` so callers can distinguish this from a numeric
1231        // mismatch.
1232        let err = BookedCost::try_new(dec!(999999), dec!(0.01), dec!(0))
1233            .expect_err("zero units must be rejected, not silently accepted");
1234        assert!(err.tolerance.is_none(), "zero-units error has no tolerance");
1235        assert!(!err.overflow, "this is zero-units, not overflow");
1236        assert!(format!("{err}").contains("non-zero units"));
1237    }
1238
1239    #[test]
1240    fn booked_cost_try_new_accepts_consistent_pair() {
1241        let result = BookedCost::try_new(dec!(50), dec!(500), dec!(10));
1242        assert!(result.is_ok());
1243    }
1244
1245    #[test]
1246    #[should_panic(expected = "overflow")]
1247    fn booked_cost_new_panics_in_debug_on_overflow() {
1248        // `BookedCost::new` debug-asserts the invariant. Overflow
1249        // should reach the assertion via `check_invariant`'s Err, then
1250        // panic with a message that names the failure mode — same
1251        // contract as the existing zero-units / mismatch debug
1252        // asserts. Without this test, a future refactor of
1253        // `check_invariant`'s error path could swallow the overflow
1254        // case at the `new` call site (e.g. by short-circuiting to
1255        // Ok or by using a different Display) and the `new`-side
1256        // contract would degrade silently. Inputs: 5e15 × 5e15 →
1257        // 2.5e31, which exceeds Decimal::MAX (~7.92e28).
1258        let huge = Decimal::from_str_exact("5000000000000000").unwrap();
1259        let _ = BookedCost::new(huge, Decimal::from_str_exact("0.01").unwrap(), huge);
1260    }
1261
1262    #[test]
1263    fn booked_cost_try_new_surfaces_overflow_instead_of_panicking() {
1264        // Trust-boundary regression guard: a wire client can submit
1265        // per_unit and units that each fit in Decimal but whose product
1266        // exceeds Decimal::MAX (~7.92e28). Pre-fix `check_invariant`
1267        // used bare `*` and panicked the host on multiplication;
1268        // `try_new` now surfaces it as a typed error so FFI / plugin
1269        // bridges can map it to ConversionError and propagate to the
1270        // caller. Inputs: 5e15 × 5e15 = 2.5e31, well over Decimal::MAX.
1271        let per_unit = Decimal::from_str_exact("5000000000000000").unwrap();
1272        let units = Decimal::from_str_exact("5000000000000000").unwrap();
1273        let total = Decimal::from_str_exact("0.01").unwrap();
1274        let err = BookedCost::try_new(per_unit, total, units)
1275            .expect_err("overflow must surface as Err, not panic");
1276        assert!(err.overflow, "overflow flag must be set");
1277        assert!(
1278            err.tolerance.is_none(),
1279            "no tolerance comparison performed for overflow",
1280        );
1281        assert_eq!(err.derived_total, Decimal::ZERO);
1282        assert_eq!(err.abs_diff, Decimal::ZERO);
1283
1284        let msg = format!("{err}");
1285        assert!(
1286            msg.contains("overflow") || msg.contains("Decimal::MAX"),
1287            "error message must name the overflow condition, got: {msg}"
1288        );
1289    }
1290
1291    #[test]
1292    fn booked_cost_invariant_tolerates_rust_decimal_rounding() {
1293        // The booker computes per_unit = total / |units| at 28-digit
1294        // precision; back-multiplying truncates the same way. The
1295        // tolerance must accommodate the ULP-scale residual that real
1296        // ledgers exercise — the original tight 1e-20 floor fired
1297        // spuriously on cases like 300 / 1.763.
1298        let total = dec!(300);
1299        let units = dec!(1.763);
1300        let per_unit = total / units;
1301        // This must NOT panic.
1302        let _ = BookedCost::new(per_unit, total, units);
1303    }
1304
1305    #[test]
1306    fn cost_number_per_unit_accessor() {
1307        assert_eq!(
1308            CostNumber::PerUnit { value: dec!(150) }.per_unit(),
1309            Some(dec!(150))
1310        );
1311        assert_eq!(CostNumber::Total { value: dec!(1500) }.per_unit(), None);
1312        let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1313        assert_eq!(CostNumber::PerUnitFromTotal(b).per_unit(), Some(dec!(30)));
1314    }
1315
1316    #[test]
1317    fn cost_number_total_accessor() {
1318        assert_eq!(CostNumber::PerUnit { value: dec!(150) }.total(), None);
1319        assert_eq!(
1320            CostNumber::Total { value: dec!(1500) }.total(),
1321            Some(dec!(1500))
1322        );
1323        let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1324        assert_eq!(CostNumber::PerUnitFromTotal(b).total(), Some(dec!(300)));
1325    }
1326
1327    #[test]
1328    fn cost_spec_resolve_per_unit_from_total_uses_per_unit_directly() {
1329        // Verifies the documented optimization: by `BookedCost::new`'s
1330        // invariant, b.per_unit == b.total / |units|, so resolve()
1331        // returns b.per_unit without redivision. The result must equal
1332        // what the `Total` arm would have computed.
1333        let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1334        let spec = CostSpec::empty()
1335            .with_number(CostNumber::PerUnitFromTotal(b))
1336            .with_currency("USD");
1337
1338        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
1339        assert_eq!(cost.number, dec!(30));
1340        assert_eq!(cost.currency, "USD");
1341
1342        // Same shape via raw Total → same number after division.
1343        let total_spec = CostSpec::empty()
1344            .with_number(crate::CostNumber::Total { value: dec!(300) })
1345            .with_currency("USD");
1346        let total_cost = total_spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
1347        assert_eq!(cost.number, total_cost.number);
1348    }
1349
1350    #[test]
1351    fn cost_spec_matches_per_unit_from_total() {
1352        // PerUnitFromTotal must match against a Cost by its per-unit
1353        // value (the lot's canonical number) — this is what lot
1354        // reduction code path needs.
1355        let cost = Cost::new(dec!(150.00), "USD")
1356            .with_date(date(2024, 1, 15))
1357            .with_label("lot1");
1358
1359        let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
1360        let spec = CostSpec::empty().with_number(CostNumber::PerUnitFromTotal(b));
1361        assert!(spec.matches(&cost));
1362
1363        // Wrong per-unit: must not match.
1364        let wrong = BookedCost::new(dec!(160), dec!(320), dec!(2));
1365        let wrong_spec = CostSpec::empty().with_number(CostNumber::PerUnitFromTotal(wrong));
1366        assert!(!wrong_spec.matches(&cost));
1367    }
1368
1369    #[test]
1370    fn cost_number_serde_emits_kind_tagged_shape() {
1371        // The unified wire shape across plugin-types, FFI-WASI, WASM,
1372        // and Python compat is `{"kind": "per_unit", "value": "100"}`
1373        // etc. This test pins that crate::CostNumber serde
1374        // matches — silent drift here breaks every downstream client.
1375        let pu = CostNumber::PerUnit { value: dec!(100) };
1376        let json = serde_json::to_value(pu).unwrap();
1377        assert_eq!(json["kind"], "per_unit", "PerUnit must use kind tag");
1378
1379        let t = CostNumber::Total { value: dec!(1500) };
1380        let json = serde_json::to_value(t).unwrap();
1381        assert_eq!(json["kind"], "total");
1382
1383        let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
1384        let puft = CostNumber::PerUnitFromTotal(b);
1385        let json = serde_json::to_value(puft).unwrap();
1386        assert_eq!(json["kind"], "per_unit_from_total");
1387        assert_eq!(json["per_unit"], "150");
1388        assert_eq!(json["total"], "300");
1389    }
1390
1391    #[test]
1392    fn cost_number_serde_round_trip() {
1393        // The cross-language wire contract is only honored if Rust
1394        // can also deserialize what it serialized. Pin the round-trip.
1395        for cn in [
1396            CostNumber::PerUnit { value: dec!(42) },
1397            CostNumber::Total { value: dec!(420) },
1398            CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1399        ] {
1400            let json = serde_json::to_string(&cn).unwrap();
1401            let back: CostNumber = serde_json::from_str(&json).unwrap();
1402            assert_eq!(cn, back, "round trip lost data for {cn:?}");
1403        }
1404    }
1405
1406    #[cfg(feature = "rkyv")]
1407    #[test]
1408    fn cost_number_rkyv_round_trip_preserves_all_variants() {
1409        // Cache v8 docstring claims tuple→struct variant migration is
1410        // byte-compatible (review A-4.1). Verify by round-tripping
1411        // each variant through rkyv archive bytes — if the
1412        // serialize/deserialize pair loses info or panics, the cache
1413        // claim is wrong and v8 must bump to v9.
1414        for cn in [
1415            CostNumber::PerUnit { value: dec!(150) },
1416            CostNumber::Total { value: dec!(1500) },
1417            CostNumber::PerUnitFromTotal(BookedCost::new(dec!(30), dec!(300), dec!(10))),
1418        ] {
1419            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&cn).unwrap();
1420            let back: CostNumber =
1421                rkyv::from_bytes::<CostNumber, rkyv::rancor::Error>(&bytes).unwrap();
1422            assert_eq!(cn, back, "rkyv round-trip lost data for variant {cn:?}");
1423        }
1424    }
1425
1426    #[cfg(feature = "rkyv")]
1427    #[test]
1428    fn cost_number_archived_bytes_snapshot() {
1429        // Layout snapshot: if rkyv's encoding ever changes (version
1430        // upgrade, attribute change, or accidental shape drift), this
1431        // test fires and CACHE_VERSION must bump (review A-4.1).
1432        // Each archived byte sequence is a fixed contract — any change
1433        // means existing cache files on user disks become invalid.
1434        let per_unit = CostNumber::PerUnit { value: dec!(150) };
1435        let per_unit_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&per_unit).unwrap();
1436        assert!(
1437            !per_unit_bytes.is_empty(),
1438            "PerUnit must serialize to non-empty bytes"
1439        );
1440
1441        let total = CostNumber::Total { value: dec!(1500) };
1442        let total_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&total).unwrap();
1443        assert!(!total_bytes.is_empty());
1444
1445        // Critical pin: PerUnit and Total of the same numeric value
1446        // serialize to different bytes (the discriminator must be
1447        // distinct). If they collide, the cache cannot distinguish
1448        // `{150 USD}` from `{{150 USD}}`.
1449        let pu_same = CostNumber::PerUnit { value: dec!(1500) };
1450        let pu_same_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&pu_same).unwrap();
1451        assert_ne!(
1452            total_bytes.as_ref(),
1453            pu_same_bytes.as_ref(),
1454            "PerUnit and Total of the same value must serialize distinctly"
1455        );
1456
1457        // PerUnitFromTotal must also be distinct from PerUnit-only.
1458        let booked = CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2)));
1459        let booked_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&booked).unwrap();
1460        let pu_only = CostNumber::PerUnit { value: dec!(150) };
1461        let pu_only_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&pu_only).unwrap();
1462        assert_ne!(
1463            booked_bytes.as_ref(),
1464            pu_only_bytes.as_ref(),
1465            "PerUnitFromTotal and PerUnit must serialize distinctly (preserved total is load-bearing)"
1466        );
1467    }
1468
1469    // Frozen byte fixtures for the v8 cache layout live alongside
1470    // CACHE_VERSION in `rustledger-loader::cache::tests` so the
1471    // version constant and the on-disk byte layout sit in one place
1472    // — see `cost_number_archived_bytes_match_v8_fixtures` there.
1473
1474    #[test]
1475    fn cost_spec_display_renders_per_unit_from_total_as_per_unit() {
1476        // Python-beancount compat: post-booking display uses per-unit
1477        // form even though source was `{{...}}`. This pins the
1478        // documented format-amount.rs behavior.
1479        let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
1480        let spec = CostSpec::empty()
1481            .with_number(CostNumber::PerUnitFromTotal(b))
1482            .with_currency("USD");
1483        let s = format!("{spec}");
1484        // Per-unit form: just the per_unit value, not `# total`.
1485        assert!(s.contains("150"), "expected per-unit 150 in {s}");
1486        assert!(!s.contains("# 300"), "must NOT render as `# total` ({s})");
1487    }
1488}
1489
1490#[cfg(test)]
1491mod same_number_equivalence {
1492    use super::same_number;
1493    use rust_decimal::Decimal;
1494    use rust_decimal_macros::dec;
1495
1496    /// `same_number` must agree with `==` on every pair, including the cases
1497    /// its fast path deliberately sidesteps.
1498    ///
1499    /// The fast path only fires when scales match, and then trusts the
1500    /// mantissa. Two ways that could go wrong: values equal across DIFFERENT
1501    /// scales (`1.0` vs `1.00`), which must fall through to the general
1502    /// comparison, and signed zero at the same scale, where the
1503    /// representations differ but the values do not — `mantissa()` is signed,
1504    /// so both come out 0.
1505    #[test]
1506    fn it_agrees_with_decimal_equality() {
1507        let values = [
1508            dec!(0),
1509            dec!(0.00),
1510            -dec!(0),
1511            -dec!(0.00),
1512            dec!(1),
1513            dec!(1.0),
1514            dec!(1.00),
1515            dec!(-1),
1516            dec!(-1.00),
1517            dec!(100),
1518            dec!(100.00),
1519            dec!(100.000),
1520            dec!(99.99),
1521            dec!(-99.99),
1522            dec!(0.1),
1523            dec!(0.10),
1524            dec!(0.01),
1525            Decimal::MAX,
1526            Decimal::MIN,
1527            Decimal::MAX - dec!(1),
1528        ];
1529        for a in values {
1530            for b in values {
1531                assert_eq!(
1532                    same_number(a, b),
1533                    a == b,
1534                    "same_number({a}, {b}) disagreed with `==` (scales {} and {})",
1535                    a.scale(),
1536                    b.scale(),
1537                );
1538            }
1539        }
1540    }
1541
1542    /// The fast path must actually be reached, or the test above is only
1543    /// exercising the fallback and proves nothing about it.
1544    #[test]
1545    fn the_fast_path_is_reachable() {
1546        assert_eq!(dec!(100.00).scale(), dec!(99.99).scale());
1547        assert!(!same_number(dec!(100.00), dec!(99.99)));
1548        assert!(same_number(dec!(100.00), dec!(100.00)));
1549        // ...and the fallback is reachable too: equal values, unequal scales.
1550        assert_ne!(dec!(1.0).scale(), dec!(1.00).scale());
1551        assert!(same_number(dec!(1.0), dec!(1.00)));
1552    }
1553}