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